Skip to content

feat(mix): batched (MMQ) path for qtypes 105/106, routed on batch width - #607

Open
geometric[bot] wants to merge 5 commits into
Luce-Org:mainfrom
GeometricAGI:ds4/mix-batched-multiply
Open

feat(mix): batched (MMQ) path for qtypes 105/106, routed on batch width#607
geometric[bot] wants to merge 5 commits into
Luce-Org:mainfrom
GeometricAGI:ds4/mix-batched-multiply

Conversation

@geometric

@geometric geometric Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

The MMQ kernels for the mix qtypes (105/106) already existed and already
plumbed the out-of-band codebooks, but were reachable only through an env var
(DFLASH_DS4_MIX_MMQ_PREFILL) and gated to RDNA. So every ne11 > 1 multiply
fell back to dequantize-to-bf16 + dense GEMM — 48% of a 16-token batch's GPU
time sat inside dequantize_rocmfp{2,3}_mix_kernel
(nsys), i.e. the multiply
discarded the sub-4 bpw artifact and ran 16-bit for its duration.

This makes that path reachable, gives it a correctness gate it never had, and
routes it per multiply on batch width — because MMQ is not uniformly better.
It wins on narrow batches and loses on wide ones, since the dequant path hands
a wide N to a well-tiled dense GEMM that the MMQ kernel does not out-tile. A
single process-wide switch therefore cannot be right: one served request wants
the narrow-batch win on its speculative-verify steps and the dense-GEMM win
on its prefill.

Also included: a dense-matvec improvement for the same qtypes (vectorized
activation loads, two output rows per warp), which is bit-exact.

CORRECTED (see eab505c and the comment below). An earlier revision of this
description said this change is dormant for DeepSeek-V4 because its 105/106
tensors are MoE experts reached through ggml_mul_mat_id, "which does not take
this path". That reason is wrong. ggml_cuda_mul_mat_id does consult
ggml_cuda_should_use_mmq and does dispatch to MMQ: measured on H200 with 8
experts / top_k 2, MMQ engages from ~64 tokens and runs 1.7-2.3x faster than the
alternative. The gate in this PR now covers that dispatch directly.

What remains true is the observation that motivated the default: an A/B on a
real DS4 artifact (gfx1151) came back byte-identical with the flag on and off,
with prefill at 184.1 s +/-0.1%. Byte-identical output is strong evidence MMQ did
not run anywhere in that graph, since MMQ quantizes activations to int8 and
provably changes results. But I do not currently have a verified explanation
for why it did not run, and that A/B predates both the width gate and eab505c
,
so it cannot be cited as this branch's behaviour. Treat DS4 impact here as
UNMEASURED pending a re-run, not as established zero.

The default therefore stays OFF with no in-tree opt-in caller — as a
conservative choice while that is unresolved, not because the path is
unreachable. The value this PR is offered on is the batched path being correct,
tested (now including MoE), and width-routed.

Impact

Scenario before after gain
gfx1151 prefill, ne11=8 / 16 / 64 (mix MMQ off -> on) 17.8 / 35.6 / 113.3 tok/s 32.0 / 62.9 / 186.8 tok/s +1.80x / +1.77x / +1.65x
gfx1201 prefill, ne11=8 / 64 / 256 (mix MMQ off -> on) 11.6 / 110.5 / 332.8 tok/s 59.3 / 360.3 / 607.7 tok/s +5.11x / +3.26x / +1.83x
gfx1151 prefill, ne11=2048 — the regression the width gate removes 0.86x with MMQ forced on 1.00x (gate declines MMQ) regression eliminated
H200 dense decode of these qtypes, matvec change baseline 2.06x, bit-identical output +106% (not reproducible from this tree — see Notes)

Implementation

  • perf(mix): vectorized activation loads and two output rows per warp in the dense 105/106 matvec. Bit-identical output — the per-block accumulation order is unchanged.
  • feat(mix): the toggle becomes a real API (ggml_cuda_mix_mmq_enabled / set / clear / env_pinned in ggml-cuda.h), precedence explicit-call > env > compiled default. Renamed DFLASH_MIX_MMQ since it was never prefill-specific; the old spelling is still honoured.
  • feat(mix): NVIDIA is no longer excluded — the DP4A/MMA tile these types declare is portable, and it now has a correctness gate rather than an assumption.
  • feat(mix): ggml_cuda_should_use_mmq width-gates the mix qtypes at mix_mmq_max_ne11 (1024 on RDNA 4, 256 elsewhere). Decode never reaches this gate: ne11 == 1 takes the MMV kernel.
  • test(mix): MMQ vs the already-validated matvec kernel across ne11 1/4/16/64 and expert counts 1 (dense mul_mat) and >1 (MoE).
  • chore(build): wires the gate into the test targets, guarded by if(EXISTS) like its neighbours.

Runtime Flags / Configuration

Default-on paths:

  • None. The compiled default is OFF and no in-tree backend opts in — see the Summary for why.
    Disable path:
  • DFLASH_MIX_MMQ=0: forces the batched path off; wins over a backend's programmatic opt-in, so an operator can always override.
  • DFLASH_DS4_MIX_MMQ_PREFILL: the previous spelling, still read as a fallback so existing scripts keep working.
    Debug/profiling-only flags:
  • DFLASH_MIX_MMQ_MAX_NE11=<n>: overrides the per-architecture width threshold, for re-sweeping the crossover.
    DFLASH_MIX_MMQ=1 enables the batched path process-wide. Precedence is
    explicit API call > environment > compiled default, so an operator running
    DFLASH_MIX_MMQ=0 is never silently overridden by a model backend.

Validation

Check Result
test_rocmfp_mix_mmq (new) on H200, default path pass — MMQ error 0.09-0.14% of
test_rocmfp_mix_mmq under DFLASH_MIX_MMQ=0 pass — covers the toggle's precedence
Existing test_rocmfp_mix_slice_matvec (covers the bit-exact matvec change) pass
Clean configure + build of ggml-cuda, test_rocmfp_mix_mmq and dflash_server from this branch on main pass (CUDA, sm_90)
DS4 A/B: 6 serving configs, a 3974-token prefill, and a controlled decode A/B on gfx1151 byte-identical outputs; 184.1 s +/-0.1%; 21.72 / 21.72 / 21.74 tok/s — measured BEFORE the width gate and eab505c, so it does not describe this branch
ggml_mul_mat_id (MoE) with mix qtypes, H200, 8 experts / top_k 2 MMQ engages from ~64 tokens; 0.116 vs 0.249 ms (105) and 0.106 vs 0.247 ms (106) at 64 tokens

Notes

Methodology, stated honestly.

  • The 2.06x matvec figure was taken with a decode bench belonging to a dense
    model line that is not part of this PR, so it is not reproducible from
    this tree alone
    . The change is bit-exact, so correctness is covered by the
    existing test_rocmfp_mix_slice_matvec. Happy to have maintainers re-run
    throughput on the RTX 3090 or Ryzen 395 AI Max per CONTRIBUTING, or to add a
    standalone in-tree bench if you would prefer one.
  • The width sweep is AMD-only (gfx1151, gfx1201), n=1 per cell, on an idle
    host. The crossover is bracketed, not resolved: 256-1024 on RDNA 3.5 and
    1024-2048 on RDNA 4. Power limit was not pinned; happy to re-run under your
    methodology.
  • NVIDIA has no width sweep — its measured result (1.20x) is from ne11
    4..16 verify batches. It therefore takes the conservative RDNA 3.5 bound,
    which keeps every measured NVIDIA win and declines only widths nothing has
    measured there. Raise with DFLASH_MIX_MMQ_MAX_NE11 once swept.
  • Throughput numbers from the Strix Halo host are co-tenancy sensitive: an
    unrelated compile on the same box moved an identical request by ~5x with the
    step count unchanged. All figures above were taken on an idle host with
    arm order reversed and a warmup discarded.

Retraction carried in the history. An earlier revision of this work
reported a 35% DS4 regression under the flag. That was a harness artifact — a
failed arm left the previous server's ~91 GiB unified allocation live, so the
next arm measured contention. The controlled re-run is the 21.72 / 21.72 /
21.74 tok/s above. The original "inert on DS4" evidence was byte-identical
outputs, which cannot distinguish "same kernels ran" from "the path declined to
engage"; that conclusion held, but it now rests on throughput instead.

One open decision for maintainers: whether you want this landed with the
default off while the DS4 question is re-measured, or held until that re-run is
done. I would rather flag the uncertainty than let the earlier "unused by DS4"
framing stand, since it rested on a mechanism I have since disproved.

Mentioning @Luce-Org/maintainers per CONTRIBUTING now that this is ready for
review.

Review in cubic

DeanoC added 5 commits August 13, 2026 13:43
…the dense matvec

The qtype 105/106 matvec read activations one scalar at a time and handled one
output row per warp. Vectorizing the loads and blocking two rows per warp
measured 2.06x for dense decode of these qtypes on an H200, with BIT-IDENTICAL
output — the per-block accumulation order is unchanged. On AMD (gfx1151,
gfx1201) the same change is neutral, so it is not gated by backend.

Methodology note: that 2.06x was taken with a decode bench belonging to a dense
model line that is NOT part of this PR, so it is not reproducible from this tree
alone. The change is bit-exact, so correctness is covered by the existing
test_rocmfp_mix_slice_matvec gate; happy to have maintainers re-run throughput on
the RTX 3090 or Ryzen 395 AI Max per CONTRIBUTING, or to add a standalone bench
if you would rather have one in-tree.

Dense consumers are what this helps: DeepSeek-V4's own 105/106 tensors are MoE
experts, which take the mul_mat_id path rather than this one.
…and route it on batch width

The MMQ kernels for both mix qtypes already existed and already plumbed the
out-of-band codebooks, but were reachable only through an env var
(DFLASH_DS4_MIX_MMQ_PREFILL) and gated to RDNA, so ne11 > 1 fell back to
dequantize-to-bf16 + dense GEMM — 48% of a 16-token batch's GPU time inside
dequantize_rocmfp{2,3}_mix_kernel (nsys), i.e. the multiply discarded the
sub-4 bpw artifact and ran 16-bit for its duration.

The toggle becomes a real API (ggml_cuda_mix_mmq_enabled / set / clear /
env_pinned in ggml-cuda.h) with precedence explicit-call > env > compiled
default, renamed DFLASH_MIX_MMQ since it was never prefill-specific; the old
spelling is still honoured. NVIDIA is no longer excluded: the DP4A/MMA tile
these types declare is portable, and a correctness gate now covers it rather
than an assumption.

MMQ is NOT uniformly better, so the choice is per multiply rather than per
process. It wins on narrow batches and loses on wide ones, because the dequant
path hands a wide N to a well-tiled dense GEMM that the MMQ kernel does not
out-tile. Measured out of tree on a dense 3.3 bpw artifact, prefill tok/s,
off -> on:

  ne11        8      16      64     256    1024    2048
  gfx1151  1.80x   1.77x   1.65x   1.05x   0.89x   0.86x
  gfx1201  5.11x   4.02x   3.26x   1.83x   1.13x   0.98x

so ggml_cuda_should_use_mmq width-gates the mix qtypes at mix_mmq_max_ne11
(1024 on RDNA 4, 256 elsewhere; DFLASH_MIX_MMQ_MAX_NE11 overrides). One request
then gets the narrow-batch win on speculative verify AND the dense-GEMM win on
prefill with nothing to configure. Re-measured with the gate in place, the
wide-batch regressions are gone (gfx1151 2048: 0.86x -> 1.00x) and every
narrow-batch win is retained. Decode is unchanged in all arms, which is the
control: ne11 == 1 takes the MMV kernel and never reaches this gate.

Default stays OFF with no in-tree opt-in caller, deliberately: DS4's 105/106
tensors are MoE experts on mul_mat_id, so this path cannot fire for them. That
is measured, not assumed — six serving configs byte-identical either way, a
3974-token prefill at 184.1 s +/-0.1%, and a controlled decode A/B (one server
at a time, hard teardown, arms ordered 0,1,1,0) at 21.72 / 21.72 / 21.74 tok/s
on gfx1151. A dense consumer of these qtypes opts in for itself and shows its
own measurement.

Methodology note: the width sweep is AMD (gfx1151/gfx1201), n=1 per cell on an
idle host, and the crossover is bracketed rather than resolved (256-1024 on RDNA
3.5, 1024-2048 on RDNA 4). NVIDIA has no width sweep, so it takes the
conservative RDNA 3.5 bound — that keeps every measured NVIDIA win and declines
only widths nothing has measured there. Power limit was not pinned; happy to
re-run under your methodology.
The batched path had no correctness test on either vendor: it was reachable only
behind an env var and gated to RDNA, so it had never executed on CUDA at all.
Enabling it (previous commit) without a gate would be a numerics change backed
by nothing.

Compares MMQ against the already-validated matvec kernel for both qtypes across
ne11 1/4/16/64 and expert counts 1 (dense mul_mat) and >1 (MoE), asserting
err(MMQ) <= tolerance * err(dequant) plus an absolute 1%-of-|ref| bar. On an
H200 MMQ lands 0.09-0.14% of |ref| and is 5-7x CLOSER to reference than the
dequant path — dequant rounds through bf16 where MMQ keeps integer dot products,
so the batched path is faster and more faithful at once. Green both on the
default path and under DFLASH_MIX_MMQ=0, which covers the toggle's precedence.
Guarded by if(EXISTS) like its neighbours, and backend-general: the kernels
declare cudaStream_t and ggml's vendors/hip.h maps the cuda* spellings onto
hip*, so one source builds for both. Kept separate from the test source per
CONTRIBUTING (build config in its own commit).
…oE in the gate

Two defects, both mine, found by extending the gate to the dispatch DeepSeek-V4
actually uses.

The width cap was wrong for MoE. Declining MMQ on a DENSE multiply hands the
work to a well-tiled dense GEMM, which is why the cap exists and why it measured
well. Declining it on a mul_mat_id does something else entirely: it falls to the
host-synchronised sort path — a cudaStreamSynchronize, a CPU-side id sort, an
expand, and no CUDA-graph capture. Same threshold, opposite consequence.
Measured on H200, 8 experts / top_k 2, q2_1_rocmfp2_mix at 512 tokens: 0.412 ms
with the cap applied to MoE vs 0.202 ms without — the cap cost 2.04x on exactly
the widths a prefill uses. The cap is now dense-only; mul_mat_id is keyed off
n_experts > 1 (the dense call sites pass 0, or 1 for the fused gate/up pair).

With MMQ reaching MoE at width, both qtypes beat the fallback at every width
where the toggle changes the kernel: 105 at 64 tokens 0.116 vs 0.249 ms, at 512
0.212 vs 0.370; 106 at 64 0.106 vs 0.247, at 512 0.202 vs 0.359.

The test claimed coverage it did not have. Its comment said 'expert counts 1
(dense mul_mat) and >1 (MoE)' while expert_set was {1}: a 3-D src0 through
ggml_mul_mat does not reach MMQ, so the multi-expert case had been removed as
vacuous and the claim was never corrected. It now exercises ggml_mul_mat_id
directly across 1/4/64/512 tokens, cross-checking MMQ against dequantize+GEMM
through the same call, reporting whether the toggle changed the result at all
(so a silently-inert case cannot pass), and timing both arms. That report is
also what located the defect above.

Fixture note: top-k ids are now sampled WITHOUT replacement. The runtime's sort
records one entry per (expert, token) and breaks on the first match, so a
duplicated expert makes it emit fewer rows than ne12*n_expert_used and trip its
own assert. Real top-k routing cannot select an expert twice; sampling with
replacement produced an abort that looked like a kernel bug and was bad data.
@DeanoC

DeanoC commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Pushed eab505c, which fixes a defect in this PR and a false coverage claim in it. Both were found by extending the gate to ggml_mul_mat_id — the dispatch DeepSeek-V4's routed experts actually use, and the one this PR changes the behaviour of.

The width cap was wrong for MoE

The cap is right for dense multiplies: declining MMQ there hands the work to a well-tiled dense GEMM, which is exactly what the measurements in the original commit show.

Declining it on a mul_mat_id does something completely different. It falls through to the host-synchronised sort path — a cudaStreamSynchronize, a CPU-side id sort, an expand, and no CUDA-graph capture. Same threshold, opposite consequence.

Measured on H200, 8 experts / top_k 2, q2_1_rocmfp2_mix, 512 tokens (a prefill-scale width):

time
cap applied to MoE (this PR as it stood) 0.412 ms
cap not applied to MoE 0.202 ms

So the cap was costing 2.04x on precisely the widths a prefill uses. It is now dense-only, keyed off n_experts > 1 — the dense call sites pass 0, or 1 for the fused gate/up pair, while ggml_cuda_mul_mat_id passes the real expert count.

With MMQ reaching MoE at width, it beats the fallback at every width where the toggle changes the kernel at all:

qtype 64 tokens (on / off) 512 tokens (on / off)
q3_1_rocmfp3_mix 0.116 / 0.249 ms 0.212 / 0.370 ms
q2_1_rocmfp2_mix 0.106 / 0.247 ms 0.202 / 0.359 ms

At 1 and 4 tokens the toggle changes nothing, because the bespoke fused mix mul_mat_id kernel takes those (ne12 <= 5) and returns before this gate is consulted.

The test claimed coverage it did not have

The commit message said the gate covers "expert counts 1 (dense mul_mat) and >1 (MoE)". It did not: expert_set was {1}. The multi-expert case had been removed as vacuous — a 3-D src0 through ggml_mul_mat does not reach MMQ, so it would have compared a path against itself — and the claim was never corrected to match. My error, and it is corrected in the new commit rather than left for review to catch.

The gate now exercises ggml_mul_mat_id directly across 1/4/64/512 tokens, cross-checking MMQ against dequantize+GEMM through the same call (two independent implementations of one operation), reporting per width whether the toggle changed the result at all so a silently-inert case cannot pass, and timing both arms. That report is what located the defect above.

One note on the fixture, since the failure was instructive

Top-k ids are sampled without replacement. The runtime's sort records one entry per (expert, token) and breaks on the first match, so a duplicated expert makes it emit fewer rows than ne12*n_expert_used and trip its own assert. Real top-k routing cannot select an expert twice, so sampling with replacement was simply bad fixture data — but the abort looked like a kernel bug for a minute, and the assert deserves credit for catching it.

Still open, and not addressed here

The ne12 <= 5 cliff on the fused mix mul_mat_id kernel remains: between 6 and ~63 tokens, a run with MMQ disabled lands in the host-sync sort path. Raising that cap, or making the sort device-side, both look worthwhile and both are larger changes than this PR should carry. Happy to open a follow-up if you want it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant